Skip to content

feat: Rust combat engine (38k LOC, 1599 tests, full modifier pipelines)#127

Closed
JackSwitzer wants to merge 22 commits intomainfrom
feat/rust-engine
Closed

feat: Rust combat engine (38k LOC, 1599 tests, full modifier pipelines)#127
JackSwitzer wants to merge 22 commits intomainfrom
feat/rust-engine

Conversation

@JackSwitzer
Copy link
Copy Markdown
Owner

@JackSwitzer JackSwitzer commented Apr 2, 2026

Summary

Full Rust combat engine for MCTS simulation with complete STS combat parity:

  • 6 combat pipelines with all relic/power modifiers wired:
    • Outgoing damage (Str, Weak, Pen Nib, Stances, Vuln, Flight, Intangible)
    • Incoming damage (Wrath, Vuln, Intangible, Block, Torii, Tungsten Rod, Paper Crane, Odd Mushroom)
    • Block gain (Dex, Frail)
    • Card cost (Confusion/Snecko)
    • Debuff application (Artifact, Ginger blocks Weak, Turnip blocks Frail)
    • Healing (Mark of Bloom blocks, Magic Flower 1.5x)
  • Cross-enemy AI via mfx effect types (Centurion/Mystic/GremlinLeader ally buffs)
  • Complete enemy AI: 50 enemies across 4 acts, deterministic move cycles
  • Power registry: 47 hook entries, dispatch by status ID
  • Card registry: All 4 classes + colorless/curses/status/temp cards
  • 226 status IDs with full reverse lookup table
  • Centralized mutations: heal_player(), gain_block_player(), player_lose_hp(), deal_damage_to_enemy()
  • PyO3 bridge: Full Python interop for MCTS integration

Test plan

  • 1599 unit tests pass (cargo test)
  • Damage calculation parity (outgoing, incoming, block, HP loss)
  • Enemy AI exhaustive move coverage (all moves reachable)
  • Relic parity tests (combat start, turn start, on card play, on victory)
  • Card effect dispatch for all effect tags
  • Cross-enemy effects (BLOCK_ALL_ALLIES, HEAL_LOWEST_ALLY, STRENGTH_ALL_ALLIES)

🤖 Generated with Claude Code

JackSwitzer and others added 2 commits April 2, 2026 09:33
Complete Rust engine (packages/engine-rs/) for MCTS-speed Slay the Spire
simulation. Squash of 90 commits from fix/phase4-final.

Content:
- 746 cards (all 4 characters, base + upgraded)
- 66 enemies across Acts 1-4 with full AI
- 65 combat relics wired with trigger hooks
- 38 potions with real effects
- 52 events
- 220 status effects on FxHashMap<StatusId, i32>

Architecture:
- Hook dispatch system (powers/hooks.rs) — static tables, auto-wiring
- Typed IDs (StatusId, CardId, RelicId) — Copy newtypes
- PyO3 bridge (StSEngine, CombatSolver, RustRunEngine)
- 480-dim observation encoding
- Full run simulation (map, shop, events, campfire, rewards)

Tests: 1691 passing, 0 failing

Known gaps (from Codex review):
- Necronomicon replay not firing
- Guardian mode shift incomplete
- EchoForm ignores stack count
- ~17 enemies unreachable from encounter pools
- No Neow phase in RunEngine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3 Opus 4.6 agents audited trigger system, enemy AI, and architecture.

Key findings:
- 7 missing trigger hook types (on_attacked, on_hp_loss, on_block_gained, etc.)
- Time Eater, Transient, Nemesis have broken enemy-specific mechanics
- No minion spawning (Collector, Automaton, Reptomancer, GremlinLeader)
- ~400 lines dead code (65 dead fns in buffs.rs alone)
- Death-check-fairy pattern duplicated 7 times
- Block gain scattered 27 places with no central hook
- Run is Act 1 only (no Neow, no act transitions)

Full details in docs/research/full-audit-2026-04-02.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JackSwitzer JackSwitzer marked this pull request as draft April 2, 2026 14:22
JackSwitzer and others added 2 commits April 3, 2026 23:17
…#129)

* feat: add v2 core types — Entity, CardInstance, Intent, Combat

Engine v2 foundation. Unified entity model where player and enemies
share the same Entity struct. CardInstance is 4 bytes (Copy).
Intent is a typed enum replacing scattered move_damage/hits/block fields.
Combat struct is the MCTS-friendly snapshot.

New file: src/combat_types.rs (alongside existing types, no migration yet)
6 new tests passing (1697 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add v2 verb functions — centralized mutation pipeline with reactions

14 verb functions, each applies mutation + fires all relevant reactions:
- deal_damage: block, Intangible, Thorns/FlameBarrier retaliation, death
- apply_hp_loss: bypass-block damage (poison/burn/constricted)
- gain_block: Juggernaut (dmg random enemy), Wave of Hand (Weak all)
- apply_debuff/buff: Artifact negation, Curl-Up, Sharp Hide, Malleable, Shifting
- draw_cards, exhaust_card, discard_card: pile ops with reaction hooks
- heal, gain_energy: capped, tracked

Overflow status system maps enemy power IDs (>=64) to reserved slots.
38 new tests (1735 total). All reactions verified by test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: CardRegistry v2 — numeric IDs, O(1) lookup, CardInstance helpers

Adds u16 card ID system alongside existing string API:
- card_id(&str) -> u16, card_def_by_id(u16) -> &CardDef, card_name(u16) -> &str
- make_card/make_card_upgraded for CardInstance construction
- is_strike(u16) precomputed bitset for Perfected Strike
- Base/upgraded cards get consecutive IDs (Strike_P=N, Strike_P+=N+1)
- 741 cards indexed, all existing string methods preserved

10 new tests (1744 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: Entity statuses from FxHashMap to [i16; 256] fixed array

Direct array indexing eliminates HashMap overhead. 512 bytes per entity,
memcpy clone. Removes rustc-hash dependency. All 1744 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: card piles from Vec<String> to Vec<CardInstance>

4-byte Copy struct per card (def_id: u16, cost: i8, flags: u8) replaces
heap-allocated strings. O(1) card lookup via def_id index, is_strike()
replaces lowercase string search, upgrade_card() replaces string mutation.
Added HolyWater card definitions (latent bug from string era).
All 1744 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: enemy intents from scattered fields to Intent enum + compact effects

Replaces move_damage/move_hits/move_block with typed Intent enum (Copy).
Replaces move_effects HashMap<String,i32> with SmallVec<[(u8,i16);4]>
using mfx:: constants. Zero heap allocation for enemy moves.
Backward-compat methods (move_damage(), set_move()) preserve API surface.
All 1744 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: centralized verb pipeline — gain_block, player_lose_hp, hook wiring

Centralizes all player block gain through gain_block_player() with
Juggernaut and Wave of Hand reactions. Centralizes HP loss through
player_lose_hp() with fairy revive, Rupture, and on_hp_loss relics.
Wires relics::on_shuffle (Sundial/Abacus), on_enemy_death (Gremlin
Horn/Specimen), on_victory (Burning Blood/Black Blood/Meat on Bone).
Adds on_enemy_hit reactions (Curl-Up, Malleable, Sharp Hide, Shifting).
Deletes dead do_enemy_turns/execute_enemy_move from engine.rs (-157 LOC).
All 1744 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: delete 1210 lines of dead code — v2 reference types, combat_verbs, dead powers

Removes combat_verbs.rs (893 LOC, superseded by CombatEngine verbs),
dead Entity/Combat/StanceV2/EnemyMeta/CombatLine from combat_types.rs,
11 dead functions from buffs.rs and enemy_powers.rs (replaced by hooks
dispatch). All 1703 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire card replay + 8 missing hooks in play_card/draw_cards

Necronomicon replay (2+ cost Attacks), EchoForm stacking fix (first N
cards per turn), Unceasing Top (draw on empty hand), Curiosity (enemy
Strength on Power play), SkillBurn (damage on Skill play), Forcefield
(decrement on card play), Charon's Ashes (damage on exhaust), Evolve
(extra draws on Status draw), Fire Breathing (damage on Status/Curse
draw). Removes dead replay_pending field. All 1703 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: unified power registry + 4 enemy bug fixes + dead code cleanup

Single-source-of-truth PowerRegistryEntry replaces 5-layer hand-wired
dispatch (PowerId enum, PowerDef struct, get_power_def(), manual hook
tables, scattered install_power match). Net -2577 lines.

Bug fixes: Time Eater TIME_WARP_ACTIVE init, Transient FADING init,
Nemesis Intangible cycling, boss minion spawning (Collector, Automaton,
Reptomancer, GremlinLeader). 9 new integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire all relic/power modifiers into the 6 combat pipelines:

- Incoming damage: Paper Crane (Weak 0.60), Odd Mushroom (Vuln 1.25)
- Debuff application: Ginger blocks Weak, Turnip blocks Frail
- Healing: centralized heal_player() with Mark of Bloom (blocks) + Magic Flower (1.5x)
- Card cost: Snecko Eye + Snecko Oil set CONFUSION status
- Secondary damage: BowlingBash/Ragnarok use calculate_damage_full (pen nib, flight, etc.)
- Potion hooks: Toy Ornithopter heals on potion use

Cross-enemy effects via new mfx types (BLOCK_ALL_ALLIES, HEAL_LOWEST_ALLY,
STRENGTH_ALL_ALLIES) processed in execute_enemy_move:
- Centurion Protect gives block to allies
- Mystic heals lowest-HP ally
- GremlinLeader Encourage buffs minions

Fix unreachable enemy moves:
- AcidSlime M/L: add Lick to cycle
- WrithingMass: MegaDebuff fires once after first BigHit
- Repulsor: 5-turn cycle (Daze x4 -> Attack)

Cleanup: fix critical match-arm bug where ~70 passive relics set
HAS_MARK_OF_BLOOM, remove dead lookup_by_status, fix stale docstrings.

1599 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@JackSwitzer JackSwitzer changed the title feat: Rust combat engine (41k LOC, 1691 tests) feat: Rust combat engine (38k LOC, 1599 tests, full modifier pipelines) Apr 4, 2026
JackSwitzer and others added 18 commits April 4, 2026 12:14
Relics implemented:
- Symbiotic Virus/Cracked Core/Nuclear Battery: channel orbs at combat start
  via deferred status flags consumed by engine
- Runic Capacitor: +3 orb slots at combat start
- Ring of the Serpent: +1 draw per turn (every turn, not just turn 1)
- Lizard Tail: revive at 50% HP on death (once per run, after Fairy check)
- Slaver's Collar: +3 energy in elite/boss fights (flag-based)
- WarpedTongs: upgrade random card in hand at turn start (uses engine RNG)
- Strange Spoon: 50% chance exhaust -> shuffle into draw pile
- Medical Kit: status cards become playable (exhaust on play, cost 0)
- Blue Candle: curse cards become playable (1 HP + exhaust, cost 0)

Bug fixes:
- Frozen Core now channels Frost (was Lightning)
- Potion deal_damage_to_enemy now respects Vulnerable, Intangible, Invincible
- Discovery potions (Attack/Skill/Power/Colorless) no longer dead code --
  early return removed, proxy card implementations now reachable

Cleanup:
- Fixed 5 stale docstrings in debuffs.rs
- Removed dead lookup_by_status from power registry

1599 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add complete player choice system for MCTS-compatible interactive
decisions: ChoiceReason/ChoiceOption/ChoiceContext types, begin_choice()
with safety guards, resolve_choice() dispatcher with 9 resolve methods
(Scry, DiscardFromHand, ExhaustFromHand, PutOnTopFromHand, PickFromDiscard,
PickFromDrawPile, DiscoverCard, PickOption, PlayCardFree).

Wire relic counter persistence via CombatState.relic_counters synced
to/from RunState.relic_flags at combat boundaries. Add RelicFlags
bitfield module with 31 boolean flags + 8 counter indices.

Add ~25 card effect handlers including Discovery, Foreign Influence,
Omniscience, Wish, Seek, Warcry, Headbutt, exhaust_choose, dual_wield,
flame_barrier, calculated_gamble, and more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add ChoiceReason::DualWield with resolve_dual_wield() that duplicates
  selected hand card (1 copy base, 2 upgraded via max_picks)
- Add ChoiceReason::UpgradeCard with resolve_upgrade_card() that sets
  UPGRADED flag on selected hand card
- Add ChoiceReason::PickFromExhaust + ChoiceOption::ExhaustCard with
  resolve_pick_from_exhaust() that moves card from exhaust to hand
- Fix: all three were using PickOption/PickFromDiscard which silently
  no-oped because resolve_pick_option only matches Named options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Status consumption:
- POTION_DRAW: draw cards after potion use (Swift Potion etc.)
- CENTENNIAL_PUZZLE_DRAW: draw 3 after unblocked damage
- RUNIC_CUBE_DRAW: draw 1 after unblocked damage (persistent)
- GREMLIN_HORN_DRAW: draw 1 + gain 1 energy on enemy kill
- EMOTION_CHIP_TRIGGER: trigger front orb passive after HP loss

Sentry stagger: middle Sentry (index 1) starts on Beam instead
of Bolt, matching the real game's alternating pattern.

on_victory: call relics::on_victory() after combat win to apply
Burning Blood, Black Blood, Meat on the Bone healing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
on_draw (in draw_cards loop):
- lose_energy_on_draw: Void loses 1 energy when drawn
- copy_on_draw: Endless Agony adds copy to hand when drawn

on_discard (new on_card_discarded method):
- draw_on_discard: Reflex draws N cards when discarded
- energy_on_discard: Tactician gains N energy when discarded
- Tracks DISCARDED_THIS_TURN for Sneaky Strike/Eviscerate
- Wires Tough Bandages and Tingsha relic triggers
- Only fires on manual discard, NOT end-of-turn discard

on_retain (in end_turn retain processing):
- Establishment: reduce retained card cost
- reduce_cost_on_retain: Sands of Time cost reduction
- grow_block_on_retain: Perseverance scaling via status counter
- grow_damage_on_retain: Windmill Strike scaling via status counter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tag mismatch fixes:
- add_shivs: Blade Dance, Cloak and Dagger now fire shiv handler
- add_wound_to_draw: Wild Strike adds Wounds to draw pile (not discard)
- add_dazed_to_draw: Reckless Charge adds Dazed to draw pile
- enlightenment_this_turn / enlightenment_permanent: both variants
- apply_lock_on: Bullseye applies Lock-On status

New simple effect handlers (25):
- HP: lose_hp, lose_hp_gain_energy, lose_hp_gain_str
- Draw: draw_to_n, draw_if_no_attacks, draw_if_few_cards_played
- Block: block_from_discard, block_if_no_block
- Conditionals: if_vulnerable_energy_draw, if_weak_energy_draw,
  weak_if_attacking, reduce_str_this_turn
- Pile: discard_random, shuffle_discard_into_draw, remove_enemy_block
- Energy: energy_per_cards_in_draw
- Status: no_draw, retain_block (Blur), the_bomb
- Card gen: add_wounds_to_hand, poison_random_multi

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add status-counter-based scaling for Rampage (+5/play), Glass Knife
(-2/play), Genetic Algorithm (+2 block/play), Ritual Dagger (+3 on
kill), and Streamline (reduce cost in piles). Add MCTS-compatible
card generation for Infernal Blade, Distraction, Jack of All Trades,
Violence, Metamorphosis, Chrysalis, and Transmutation using
deterministic representative cards. 6 new tests, 1612 total passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nce overrides

Add 9 choice-based card handlers: Secret Weapon/Technique (search draw
pile by type), Hologram (return from discard to hand), Forethought
(hand to bottom at cost 0), Recycle (exhaust for energy), Concentrate
(discard N gain energy), Purity (exhaust from hand), Setup (hand to
top at cost 0), Thinking Ahead (draw 2 then put 1 on top).

Fix effective_cost_inst/effective_cost_mut_inst to use CardInstance.cost
when >= 0, falling back to CardDef.cost only when instance cost is -1
(the default). This ensures runtime cost modifications (Streamline,
Madness, Setup, etc.) are properly respected for both playability
checks and energy deduction. Also adds FLAG_FREE check.

7 new tests (1613 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Conflicts:
#	packages/engine-rs/src/card_effects.rs
#	packages/engine-rs/src/tests/test_integration.rs
Fix OrbSlots.orbs -> .slots, construct PassiveEffect from orb type
for Emotion Chip trigger, add missing relics import in run.rs.

1619 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Power installs: Phantasmal Killer (double damage), Biased Cognition
(focus + decay), Amplify, Self Repair (end-of-combat heal), Corpse
Explosion (AoE on enemy death), Equilibrium (retain hand), Sentinel
(energy on exhaust under Corruption), Escape Plan (conditional block).

Dynamic cost: Blood for Blood (-1 per HP lost), Force Field (-1 per
active power), Eviscerate (-1 per discard this turn). All wired in
both effective_cost_inst and effective_cost_mut_inst.

Innate: scan draw pile after shuffle in start_combat, move cards with
"innate" tag to top so they appear in opening hand.

Misc: Sneaky Strike energy refund, HP_LOSS_THIS_COMBAT tracking,
count_active_powers helper, block_if_skill skip in standard block
path. 7 new tests (1626 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…multi-hit

Critical fixes:
- Draw pile top inverted in 4 resolvers (Scry, Warcry, Headbutt, Setup)
  used insert(0) instead of push() — put cards on bottom not top
- Sharp Hide now uses player_lose_hp() instead of direct hp -=
- Double Establishment cost reduction removed from effective_cost
- Vigor/Pen Nib only applied on first hit of Bowling Bash and Ragnarok
- Gambling Chip restricted to turn 1 only
- Runic Cube draw status zeroed after consuming
- Beat of Death checks player death between enemy iterations

High fixes:
- Bouncing Flask+ unified with base handler (base_magic=4 for 4 bounces)
- Storm of Steel+ uses same handler, generates Shiv+ via card name check
- Necronomicon checks effective cost instead of CardDef base cost
- Meditate uses begin_choice for proper card selection from discard
- Wish Gold option is now a no-op (can't modify run gold in combat)
- Forethought correctly uses insert(0) for bottom of draw pile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nctions

Step 0 of modular card effect migration. Adds src/effects/ with:
- EffectFlags: 256-bit bitset for O(1) tag checking (replaces O(n) string scan)
- CardEffectEntry: static fn pointer table per effect tag (mirrors powers/registry.rs)
- 8 dispatch functions: can_play, modify_cost, modify_damage, on_play, on_retain,
  on_draw, on_discard, post_play_dest
- Precomputed hook masks via OnceLock for fast early-out
- CardRegistry.effect_flags_vec computed at init time

Registry is empty — no behavior change. All 1629 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Stab cost

Bug 1: Omniscience now picks from draw pile (PlayCardFreeFromDraw)
instead of incorrectly picking from hand.

Bug 2: Perseverance grow_block_on_retain and Windmill Strike
grow_damage_on_retain bonuses are now read back in damage/block
calculations, not just written during end_turn retain hooks.

Bug 3: Masterful Stab cost_increase_on_hp_loss handler added to
both effective_cost_inst and effective_cost_mut_inst, increasing
cost by total_damage_taken (opposite of Blood for Blood).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces O(n) string `contains()` checks with O(1) bitset operations:
- can_play: unplayable, only_attack_in_hand, only_attacks_in_hand, only_empty_draw
- modify_cost: cost_reduce_on_hp_loss, reduce_cost_per_power, cost_reduce_on_discard, cost_increase_on_hp_loss
- on_retain: reduce_cost_on_retain, grow_block_on_retain, grow_damage_on_retain
- on_draw: lose_energy_on_draw, copy_on_draw
- on_discard: draw_on_discard, energy_on_discard
- post_play_dest: shuffle_self_into_draw, end_turn

Unifies duplicate cost modification code in effective_cost_inst/effective_cost_mut_inst.
All 1629 tests pass with no behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces damage preamble string checks with dispatch_modify_damage:
- heavy_blade, damage_equals_block, damage_plus_mantra, perfected_strike
- rampage, glass_knife, ritual_dagger, searing_blow
- grow_damage_on_retain (Windmill Strike, dual on_retain + modify_damage)
- damage_random_x_times (skip generic damage flag)

DamageModifier struct merges base_damage_override, base_damage_bonus,
strength_multiplier, and skip_generic_damage across all active hooks.
Perseverance block bonus migrated to EffectFlags bit check.

All 1629 tests pass with no behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@JackSwitzer
Copy link
Copy Markdown
Owner Author

Superseded by #131 -- all rust-engine commits are contained in feat/declarative-effects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant